
Next.js ISR의 잘못된 설정으로 Lambda 함수가 과도하게 실행되던 문제를 SSR과 CloudFront 캐싱으로 해결한 실전 경험을 공유합니다.
사이트를 SST로 배포하고 나중에 시간이 지나서 AWS를 확인해보니 Lambda가 하루 86,000번 이상 실행되면서 하루에 10달러 씩 과금 되고 있었습니다.
마치 수도꼭지를 잠그지 않고 외출한 것처럼, Lambda 함수가 쉬지 않고 실행되고 있었던 것입니다.
이 글에서는 Next.js의 ISR(Incremental Static Regeneration) 설정 실수로 인한 Lambda 과부하 문제를 진단하고, SSR과 CloudFront 캐싱으로 해결한 과정을 공유합니다. ISR과 SSR의 차이점을 명확히 이해하고, 프로젝트에 맞는 렌더링 전략을 선택하는 방법을 알아보겠습니다.
📊 비정상적인 Lambda 메트릭
├─ 호출 횟수: 86,522회/일 (초당 약 1회)
├─ 타임아웃: 거의 모든 요청 실패
├─ 실행 시간: 대부분 10초 이상
└─ 사이트 상태: 프로덕션에 최신 데이터 반영 안됨
이상한 점들:
전체 아키텍처:
Obsidian 노트 → S3 버킷 (마크다운 파일)
↓
Next.js 15 App Router
↓
AWS Lambda (SST 배포)
↓
CloudFront CDN
↓
사용자
문제의 코드:
// 모든 페이지 파일에 설정됨
export const revalidate = 60; // 1분**ISR(Incremental Static Regeneration)**은 Next.js의 기능으로, 정해진 시간마다 자동으로 페이지를 다시 생성합니다.
비유로 이해하기:
ISR을 알람 시계라고 생각해보세요. revalidate = 60으로 설정하면:
1분마다 알람이 울림
├─ 방문자가 있든 없든
├─ 새벽 3시든 한낮이든
└─ 정확히 1분마다 페이지 재생성
마치 빵집이 손님이 없어도 1분마다 새 빵을 굽는 것과 같습니다.
기본 계산:
7개 페이지 × 24시간 × 60분 = 10,080회/일
실제로는 더 많은 이유:
├─ CloudFront의 각 엣지 로케이션이 독립적으로 요청
├─ 타임아웃 시 AWS가 자동 재시도 (최대 3번)
└─ 결과: 86,522회/일 실제 실행
설정:
// sst.config.ts
timeout: "10 seconds"📚 참고: AWS Lambda Timeout 설정
Lambda 함수 시작
↓
S3에서 12개 마크다운 파일 읽기 (6초)
↓
마크다운을 HTML로 파싱 (4초)
↓
총 10초 이상 소요
↓
10초 타임아웃! ❌
비유로 이해하기:
마치 10분 안에 마라톤을 완주하라고 하는 것과 같습니다. 실제로는 12분이 필요한데, 10분만 주어진 상황입니다.
1차 시도: 타임아웃 (10초)
↓
AWS 자동 재시도 #1 (10초)
↓
다시 타임아웃
↓
AWS 자동 재시도 #2 (10초)
↓
다시 타임아웃
↓
AWS 자동 재시도 #3 (10초)
↓
결과: 1번의 요청이 4번 실행됨
타임아웃으로 ISR 재생성이 계속 실패하면, Next.js는 마지막으로 성공한 정적 HTML을 계속 제공합니다.
프로덕션 사이트: 10월 4일 데이터
(ISR 실패로 업데이트 안됨)
로컬 환경: 10월 14일 데이터
(S3에서 직접 읽어서 항상 최신)
S3 버킷: 10월 14일 데이터
(Obsidian에서 동기화됨)
마치 고장난 자판기가 같은 음료만 계속 내놓는 것과 같습니다.
먼저 두 방식의 차이를 명확히 이해해야 합니다.
ISR (Incremental Static Regeneration):
export const revalidate = 60; // 초 단위📚 참고: Next.js ISR 공식 문서
SSR (Server-Side Rendering):
export const dynamic = 'force-dynamic';변경 전:
// app/page.tsx
export const revalidate = 60; // 시간 기반
export default async function HomePage() {
const posts = await getPosts();
return <PostList posts={posts} />;
}변경 후:
// app/page.tsx
export const dynamic = 'force-dynamic'; // 요청 기반
export default async function HomePage() {
const posts = await getPosts();
return <PostList posts={posts} />;
}// 1. 홈페이지
// app/page.tsx
export const dynamic = 'force-dynamic';
// 2. 포스트 목록
// app/posts/page.tsx
export const dynamic = 'force-dynamic';
// 3. 포스트 상세
// app/posts/[...slug]/page.tsx
export const dynamic = 'force-dynamic';
// 4. 카테고리 페이지
// app/category/[...slug]/page.tsx
export const dynamic = 'force-dynamic';
// 5. 태그 페이지
// app/tags/[slug]/page.tsx
export const dynamic = 'force-dynamic';
// 6. 사이트맵
// app/sitemap.ts
export const dynamic = 'force-dynamic';변경 전: 86,522회/일 (시간 기반 자동 실행)
변경 후: 약 3,500회/일 (실제 방문자 기반)
개선율: 96% 감소
권장 공식:
타임아웃 = 평균 실행 시간 × 2.5 ~ 3배
예시:
평균 실행 시간: 12초
권장 타임아웃: 30초 (약 2.5배)
왜 2.5~3배?
// sst.config.ts
import { SSTConfig } from "sst";
import { NextjsSite } from "sst/constructs";
export default {
config(_input) {
return {
name: "my-nextjs-blog",
region: "us-east-1",
};
},
stacks(app) {
app.stack(function Site({ stack }) {
const site = new NextjsSite(stack, "Site", {
path: ".",
timeout: "30 seconds", // ✅ 10초에서 30초로 증가
environment: {
// 환경 변수...
},
});
stack.addOutputs({
SiteUrl: site.url,
});
});
},
} satisfies SSTConfig;📚 참고: SST NextjsSite Construct
변경 전:
├─ 타임아웃: 매번 발생
├─ 재시도: 3번씩
└─ 총 실행: 요청 1회당 4번
변경 후:
├─ 타임아웃: 0건
├─ 재시도: 0번
└─ 총 실행: 요청 1회당 1번
SSR로 바꾸면 방문할 때마다 Lambda가 실행됩니다. 하지만 CloudFront 캐싱을 추가하면 같은 페이지를 여러 번 방문해도 Lambda가 한 번만 실행됩니다.
비유로 이해하기:
CloudFront는 편의점 배송 시스템과 같습니다:
캐싱 없음 (SSR만):
매번 공장(Lambda)에서 제품 생산 → 배송
= 느리고 비효율적
캐싱 있음 (SSR + CloudFront):
첫 주문 → 공장에서 생산 → 편의점(CDN)에 재고 비치
다음 주문 → 편의점에서 즉시 제공 (5분간)
재고 소진 → 공장에 다시 주문
= 빠르고 효율적
📚 참고: CloudFront 캐싱 동작 방식
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// 기존 설정들...
async headers() {
return [
{
// 모든 페이지에 캐싱 헤더 추가
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, s-maxage=300, stale-while-revalidate=60',
},
],
},
];
},
};
module.exports = nextConfig;📚 참고: Next.js Headers 설정
'public, s-maxage=300, stale-while-revalidate=60'
public
├─ 누구나 캐시 가능 (CDN, 브라우저)
└─ 반대: private (사용자 브라우저만)
s-maxage=300
├─ CloudFront가 5분간 캐시
├─ 5분 동안 Lambda 실행 안 함
└─ s-는 "shared"의 약자 (CDN용)
stale-while-revalidate=60
├─ 캐시 만료 후 60초간 "오래된 캐시" 제공
├─ 동시에 백그라운드에서 새 버전 생성
└─ 사용자는 빠른 응답, 다음 사용자는 새 버전👤 방문자 A (첫 방문, 오전 10:00)
└─ CloudFront: 캐시 없음
└─ Lambda 실행 (2초)
└─ 결과를 CloudFront에 저장 (5분간 유효)
👤 방문자 B (오전 10:02)
└─ CloudFront: 캐시 히트! ⚡
└─ 캐시된 HTML 즉시 반환 (0.1초)
└─ Lambda 실행 안 함
👤 방문자 C, D, E... (오전 10:00~10:05)
└─ 모두 캐시 히트
└─ Lambda 실행 안 함
👤 방문자 Z (오전 10:06, 캐시 만료)
└─ CloudFront: 캐시 만료
└─ Lambda 재실행 (2초)
└─ 새 캐시 저장 (다시 5분간)
예시: 5분 동안 100명 방문
캐싱 없음:
100명 × 1번 = 100번 Lambda 호출
5분 캐싱:
첫 방문자만 Lambda 호출 = 1번
나머지 99명은 캐시 제공
결과: 99% Lambda 호출 감소
| 항목 | Before | After | 변화 |
|---|---|---|---|
| Lambda 호출 | 86,522회/일 | 3,500회/일 | -96% ⬇️ |
| Lambda 타임아웃 | 매번 발생 | 0건 | -100% ⬇️ |
| 페이지 로딩 | 10초+ (타임아웃) | 2-5초 | ⚡ 빠름 |
| 데이터 최신성 | 10일 전 | 최대 5분 지연 | ✅ 해결 |
| CDN 히트율 | 낮음 | 95%+ | 📈 향상 |
변경 전:
├─ 사이트 상태: 불안정
├─ 에러율: 높음 (대부분 타임아웃)
└─ 데이터: 오래됨
변경 후:
├─ 사이트 상태: 안정적
├─ 에러율: 0%
└─ 데이터: 최신 (최대 5분 지연)
예시: 일 방문자 10만 명
SSR: 100,000번 Lambda 호출
ISR (revalidate=60): 약 10,000번 Lambda 호출
→ ISR이 10배 효율적!
적합한 예시:
📚 참고: Next.js ISR 사용 사례
예시: 실시간 스포츠 점수
revalidate=30 (30초마다 업데이트)
→ 항상 최신 정보 제공
적합한 예시:
ISR의 위험: 빌드가 느리면 타임아웃
안전한 경우:
├─ 빌드 시간 < 5초
└─ 타임아웃 설정 > 빌드 시간 × 3
예시: 일 방문자 1,000명 (저의 경우)
SSR (5분 캐싱): 약 288번 Lambda 호출
ISR (revalidate=60): 약 10,000번 Lambda 호출
→ SSR이 35배 효율적!
적합한 예시:
예시: 기술 문서
업데이트 주기: 며칠 ~ 몇 주
→ ISR로 자주 재생성할 필요 없음
적합한 예시:
예시: 사용자 대시보드
각 사용자마다 다른 내용
→ ISR로는 불가능, SSR 필수
적합한 예시:
내 사이트의 특성을 파악해보세요
│
├─ 일일 방문자가 10만 이상인가?
│ │
│ ├─ Yes → ISR 사용 (revalidate=300~3600)
│ │ + CloudFront 캐싱
│ │
│ └─ No → 콘텐츠가 시간/분 단위로 변경되나?
│ │
│ ├─ Yes → ISR 사용 (revalidate=60~300)
│ │ + CloudFront 캐싱
│ │
│ └─ No → SSR 사용 (force-dynamic) ✅
│ + CloudFront 캐싱 (5~10분)
│
│ 💡 개인 블로그는 대부분 이것!
| 콘텐츠 유형 | ISR revalidate | CloudFront 캐싱 |
|---|---|---|
| 실시간 (주식, 점수) | 30~60초 | 1분 |
| 자주 업데이트 (뉴스) | 5~10분 | 5분 |
| 정기 업데이트 (블로그) | 1시간 or SSR | 5~10분 |
| 드물게 업데이트 (문서) | SSR | 10~30분 |
AWS Console에서 확인:
판단 기준:
✅ 정상: 일일 호출 ≤ 방문자 수 × 페이지뷰
⚠️ 주의: 일일 호출 > 방문자 수 × 10
🚨 문제: 일일 호출 > 10,000 (소규모 사이트)
AWS Console에서 확인:
fields @timestamp, @message
| filter @message like /timeout|timed out/i
| sort @timestamp desc
| limit 100
결과 해석:
결과 없음 → ✅ 정상
"Task timed out" 많음 → 🚨 타임아웃 문제
CloudWatch Logs Insights에서 다음 쿼리 실행:
fields @timestamp, @duration
| stats avg(@duration) as avg_duration,
max(@duration) as max_duration,
min(@duration) as min_duration
| limit 1
타임아웃 설정 공식:
권장 타임아웃 = max_duration × 1.5 ~ 2
예시:
최대 실행 시간: 15초
권장 타임아웃: 22~30초
모든 동적 페이지를 SSR로 변경:
// app/page.tsx (홈페이지)
export const dynamic = 'force-dynamic';
export default async function HomePage() {
// 기존 코드...
}// app/posts/[...slug]/page.tsx (포스트 상세)
export const dynamic = 'force-dynamic';
export default async function PostPage({
params
}: {
params: { slug: string[] }
}) {
// 기존 코드...
}거의 변경 안 되는 페이지는 긴 revalidate 유지:
// app/about/page.tsx (회사 소개)
export const revalidate = 86400; // 24시간 (하루 1번만)
export default function AboutPage() {
// 기존 코드...
}// sst.config.ts
import { SSTConfig } from "sst";
import { NextjsSite } from "sst/constructs";
export default {
config(_input) {
return {
name: "my-blog",
region: "us-east-1",
};
},
stacks(app) {
app.stack(function Site({ stack }) {
const site = new NextjsSite(stack, "Site", {
path: ".",
timeout: "30 seconds", // ✅ 타임아웃 증가
environment: {
// 환경 변수...
},
});
stack.addOutputs({
SiteUrl: site.url,
});
});
},
} satisfies SSTConfig;// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
// 모든 페이지에 5분 캐싱
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, s-maxage=300, stale-while-revalidate=60',
},
],
},
];
},
};
module.exports = nextConfig;페이지별 다른 캐싱 시간 (선택사항):
async headers() {
return [
{
// 홈: 짧은 캐싱 (자주 변경)
source: '/',
headers: [{
key: 'Cache-Control',
value: 'public, s-maxage=60',
}],
},
{
// 포스트: 긴 캐싱 (드물게 변경)
source: '/posts/:slug*',
headers: [{
key: 'Cache-Control',
value: 'public, s-maxage=600',
}],
},
];
}# 프로젝트 루트 디렉토리에서
npx sst deploy --stage production배포 후 반드시 캐시를 비워야 새 설정이 적용됩니다.
AWS Console에서:
/* 입력 (모든 캐시 무효화)왜 필요한가요? 이전 설정으로 만들어진 캐시가 CloudFront에 남아있어, 새 설정이 바로 반영되지 않기 때문입니다.
CloudWatch Logs 실시간 확인:
AWS Console에서:
정상 로그 예시:
START RequestId: abc123...
[INFO] Request: GET /posts/my-article
[INFO] S3 fetch completed in 1200ms
[INFO] Markdown parsing completed in 800ms
END RequestId: abc123... Duration: 2134.56 ms
REPORT RequestId: abc123... Status: 200
# 5분 이내 다음 방문
(로그 없음 = CloudFront 캐시 히트 = 정상 ✅)
# 5분 후 첫 방문
START RequestId: def456...
...
Lambda 호출 횟수 확인:
CloudWatch Metrics에서:
예상 결과:
변경 전: 86,000회
변경 후: 3,000~5,000회 ✅
해결책: CloudFront 캐싱으로 대부분 완화됨
첫 방문자: 1-3초 (Lambda 실행)
이후 방문자 (5분 내): 0.1초 (캐시 히트)
글을 업데이트했는데...
├─ CloudFront 캐시: 최대 5분 지연
└─ 즉시 반영 안 됨
1. 급한 경우: 수동 캐시 무효화
AWS Console → CloudFront → Invalidations
→ 특정 경로만 무효화: /posts/urgent-article
2. 자주 업데이트하는 페이지: 짧은 캐싱
// 뉴스 페이지는 1분 캐싱
source: '/news/:slug*',
headers: [{
key: 'Cache-Control',
value: 'public, s-maxage=60', // 1분
}]3. 캐시 버스팅 (고급)
// URL에 버전 추가로 캐시 우회
const url = `/posts/article?v=${lastUpdateTime}`;현재는 SSR이 효율적이지만, 트래픽이 크게 늘면 ISR이 더 나을 수 있습니다.
재평가 시점:
일 방문자가 다음을 넘으면:
├─ 10,000명: 캐싱 시간 늘리기 (10분)
├─ 50,000명: ISR 고려 시작
└─ 100,000명: ISR로 전환 권장
📚 참고: Next.js 렌더링 전략 선택 가이드
너무 짧으면:
타임아웃 발생 → 재시도 → 비효율
너무 길면:
실제 2초만 걸려도 30초 동안 Lambda 점유
→ 동시성 제한에 도달 가능
권장:
평균 실행 시간을 주기적으로 모니터링
└─ CloudWatch에서 확인
└─ 평균 × 2.5~3배로 조정
□ ISR → SSR 전환 완료
├─ revalidate 제거
└─ dynamic = 'force-dynamic' 추가
□ Lambda 타임아웃 증가
├─ 현재 평균 실행 시간 확인
└─ 2.5~3배로 설정
□ CloudFront 캐싱 헤더 추가
├─ s-maxage 설정 (300 권장)
└─ stale-while-revalidate 설정
□ 로컬에서 빌드 테스트
└─ npm run build 성공 확인
□ CloudFront 캐시 무효화 실행
└─ AWS Console에서 수동 실행
□ 사이트 동작 확인 (30분)
├─ 모든 페이지 정상 로딩
├─ 최신 콘텐츠 반영
└─ 콘솔 에러 없음
□ Lambda 로그 확인 (1시간)
├─ 타임아웃 0건
├─ 에러 0건
└─ 실행 시간 < 타임아웃
□ 24시간 후 재확인
├─ Lambda 호출 횟수 감소
└─ CloudWatch 알람 없음
□ 주간 점검
├─ Lambda 호출 횟수 추이
└─ 평균 실행 시간 변화
□ 월간 점검
├─ 트래픽 증가 여부
└─ 렌더링 전략 재평가 필요성
□ CloudWatch 알람 설정
├─ Lambda 호출 > 10,000/일
├─ Lambda 타임아웃 > 10건/시간
└─ Lambda 에러율 > 1%
모든 페이지를 같은 방식으로 렌더링할 필요는 없습니다. 페이지 특성에 맞게 혼합하세요.
// 홈: 자주 업데이트 → ISR 짧은 주기
// app/page.tsx
export const revalidate = 300; // 5분
// 포스트 목록: 보통 → SSR
// app/posts/page.tsx
export const dynamic = 'force-dynamic';
// 포스트 상세: 드뭄 → ISR 긴 주기
// app/posts/[slug]/page.tsx
export const revalidate = 86400; // 24시간
// About: 거의 안 바뀜 → 정적 생성
// app/about/page.tsx
// (아무 설정 없으면 빌드 시 정적 생성)Lambda 대신 CloudFront Edge에서 실행하여 지연시간을 더 줄일 수 있습니다.
// app/posts/[slug]/page.tsx
export const runtime = 'edge'; // 🚀 Edge Runtime
export const dynamic = 'force-dynamic';장점:
단점:
📚 참고: Next.js Edge Runtime
// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
// 조회수 상위 10개 포스트만 빌드 시 생성
const popularPosts = await getPopularPosts(10);
return popularPosts.map((post) => ({
slug: post.slug,
}));
}
// 나머지 포스트는 첫 방문 시 생성
export const dynamicParams = true;
export const revalidate = 86400; // 24시간효과:
이번 경험을 통해 중요한 교훈을 얻었습니다:
ISR은 Next.js의 강력한 기능이지만, 모든 상황에 맞는 것은 아닙니다. 내 프로젝트의 특성(트래픽, 업데이트 빈도)을 먼저 파악하고, 그에 맞는 렌더링 전략을 선택하는 것이 중요합니다.
revalidate = 60은 누군가에게는 완벽할 수 있지만, 트래픽이 적은 내 블로그에는 과했습니다. 공식 문서의 예제를 복사하기 전에, 내 상황에 맞는지 항상 고민해야 합니다.
네트워크 지연, S3 응답 시간 변동 등 예측하기 어려운 요소들을 고려하여, 평균 실행 시간의 2-3배로 설정하는 것이 안전합니다.
SSR이든 ISR이든, CloudFront 캐싱 없이는 비효율적입니다. 캐싱은 성능 최적화의 핵심입니다.
CloudWatch 알람 설정 덕분에 문제를 빠르게 발견할 수 있었습니다. 배포 후 모니터링은 필수입니다.
이 글이 Next.js와 AWS Lambda를 사용하는 분들께 도움이 되길 바랍니다. 특히 ISR과 SSR의 차이를 이해하고, 프로젝트에 맞는 렌더링 전략을 선택하는 데 도움이 되었으면 좋겠습니다.
궁금한 점이나 다른 경험이 있으시다면 댓글로 공유해주세요! 🙌